fix(gemini): honor apiBase/headers/timeout/proxy/TLS on all paths - #13060
fix(gemini): honor apiBase/headers/timeout/proxy/TLS on all paths#13060aaronlippold wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes the Gemini OpenAI-adapter integration so that all Gemini request paths (chat/stream + embed) consistently honor Continue configuration: apiBase, requestOptions.headers, timeout, and proxy/TLS settings. It also improves Gemini error surfacing by unwrapping nested SDK error payloads, and aligns token usage accounting with OpenAI-compatible semantics for “thinking” tokens.
Changes:
- Add a requestOptions-aware fetch wrapper that can apply proxy/TLS via
customFetch, adapt node-fetch-style Responses back to native WHATWG Responses, and (attempt to) prevent cross-config credential bleed during global fetch swaps. - Pass
apiBase/headers/timeout into the@google/genaiSDK viahttpOptions, switch embeddings to the SDK-nativeembedContentpath, and normalize nested Gemini errors. - Expand no-mocks e2e coverage for proxy + TLS behaviors and update live model IDs in the main adapter test suite.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/openai-adapters/src/util/requestOptionsFetch.ts | Introduces requestOptions-aware fetch swapping + Response adaptation for SDK streaming under proxy/TLS/env-proxy conditions. |
| packages/openai-adapters/src/apis/Gemini.ts | Uses requestOptions-aware fetch for SDK calls, passes httpOptions, normalizes nested errors, adjusts usage accounting, and switches embeddings to SDK-native path. |
| packages/fetch/src/index.ts | Re-exports getProxyFromEnv for shared env-proxy detection semantics. |
| packages/openai-adapters/src/test/main.test.ts | Updates Gemini live test model IDs to *-latest aliases. |
| packages/openai-adapters/src/test/gemini-adapter.vitest.ts | Adds unit tests for httpOptions construction, nested error normalization, usage accounting, and SDK-native embeddings mapping. |
| packages/openai-adapters/src/test/gemini-fetch-adaptation.vitest.ts | Adds tests for node-fetch→native Response adaptation, wrapper engagement, restoration-on-throw, and swap serialization behavior. |
| packages/openai-adapters/src/test/gemini-proxy-e2e.vitest.ts | Adds no-mocks proxy e2e suite (real local forward proxies + stub Gemini server) including env proxy and concurrent-config isolation coverage. |
| packages/openai-adapters/src/test/gemini-tls-e2e.vitest.ts | Adds no-mocks TLS/mTLS e2e suite using an openssl-generated mini CA and HTTPS stubs. |
| packages/openai-adapters/src/test/gemini-test-helpers.ts | Shared SSE helper for Gemini proxy/TLS e2e rigs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { | ||
| nativeFetch, | ||
| nativeHeaders, | ||
| nativeRequest, | ||
| nativeResponse, | ||
| withNativeFetch, | ||
| } from "./nativeFetch.js"; |
| if (!hasProxyOrTlsOptions(requestOptions) && !hasEnvironmentProxy()) { | ||
| return withNativeFetch(fn); | ||
| } |
There was a problem hiding this comment.
Good instinct to check, but this interleaving is mechanically impossible: withNativeFetch swaps and restores the globals within a single synchronous slice — the restore runs in its finally the moment the callback returns its promise, so the fast path's swap window contains zero await points. Single-threaded JS therefore cannot schedule any other task inside that window, and the fast path can never overwrite a wrapped window's fetch mid-establishment (nor observe one across an await — there is no await inside its window).
The mutex covers the only windows that do span awaits: the proxy/TLS path, where establishment awaits inside the swap. We actually wrote an adversarial test asserting exactly the race described here during development — it could not reproduce any interleaving, which is why the fast path is deliberately left unserialized (serializing it would throttle all default-config calls for no safety gain).
| const normalized: Error & { status?: number; cause?: unknown } = new Error( | ||
| extracted.message, | ||
| ); | ||
| normalized.status = status; | ||
| normalized.cause = error; | ||
| return normalized; | ||
| } |
There was a problem hiding this comment.
The original error — with its full name, stack, and properties — is preserved as cause on the normalized error (standard error chaining; Node prints the cause chain in stack traces and util.inspect). So no debugging information is lost: the wrapper's stack points at the normalization site, and cause carries the original failure's stack, which is the conventional shape for wrapped errors. Copying the original stack onto the wrapper would misattribute where the wrapper was created.
The Gemini adapter built the @google/genai client with only apiKey, so apiBase, requestOptions.headers, timeout, and all proxy/TLS options were dropped on the chat/stream path while embed() honored them. Corporate proxy and custom-gateway users could not reach Gemini even though the same key worked in Google's own samples. - Pass httpOptions (baseUrl/apiVersion/headers/timeout) into the SDK client - Route SDK calls through a serialized global-fetch swap so proxy/TLS requestOptions apply; a promise-chain mutex serializes only the establishment window to prevent cross-config credential leaks - Honor HTTPS_PROXY/HTTP_PROXY env vars (request-time precedence and NO_PROXY bypass stay with customFetch) - Surface nested Gemini error JSON instead of "Unknown error" - SDK-native embedContent (fixes bare-model-name URL parsing and wrong response field); include thoughtsTokenCount in completion_tokens Tests: no-mocks e2e rigs for proxy routing, TLS + mTLS (mini-CA), custom headers, embed-via-proxy, env-proxy precedence, timeout abort, and cross-config credential isolation. Refs continuedev#13052 Closes continuedev#12008 Authored by: Aaron Lippold<lippold@gmail.com>
1242ea4 to
f0f8c31
Compare
Review feedback: the identifier is re-exported for tests but unused in this module. Authored by: Aaron Lippold<lippold@gmail.com>
Description
Part 1 of 4 of #13052 (tracking issue with full root-cause analysis and the PR split plan).
The Gemini adapter built the
@google/genaiclient with onlyapiKey, soapiBase,requestOptions.headers,timeout, and all proxy/TLS options were silently dropped on the chat/stream path (whileembed()honored them). Users behind corporate proxies or custom gateways couldn't reach Gemini even though the same key worked in Google's own samples — Python'srequestshonorsHTTPS_PROXY/REQUESTS_CA_BUNDLEout of the box, which is why the plain sample works where a gateway-fronted Continue config does not.This PR makes the adapter honor the full config on every path:
httpOptionspassed to the SDK client —apiBase→baseUrl, plusrequestOptions.headersandtimeoutrequestOptions(proxy,caBundlePath,clientCertificate,verifySsl), since@google/genaiexposes no public fetch-injection hook. The swap window is serialized with a promise-chain mutex so concurrent configs can never observe each other's credentials; only establishment is serialized — stream consumption stays concurrent.HTTPS_PROXY/HTTP_PROXY/NO_PROXYenv vars honored — the adapter engages the proxy-capable fetch path when the standard proxy env vars are set; request-time precedence (config over env) andNO_PROXYbypass stay entirely with@continuedev/fetch'sfetchwithRequestOptionsRESOURCE_EXHAUSTEDresponses carry the real message double-nested in JSON; the adapter now unwraps it (depth-capped) instead of surfacing raw JSON / "Unknown error"embed()— the hand-rolled REST path parsed bare model names as a URL scheme (discardingapiBase) and read the wrong response field;models.embedContentfixes both and deletes the duplicated pathcompletion_tokensnow includesthoughtsTokenCount, matching the OpenAI-compatible usage spec (Google'stotalTokenCountalready includes thoughts, sototal != prompt + completionbefore this)Notes for reviewers (also in #13052):
main.test.ts's live suite moves fromgemini-2.5-*to the-latestaliases so it runs for newly created API keys (gemini-2.5-*is gated for new keys); trade-off is the suite is no longer pinned to a fixed model.packages/openai-adapters— no changes to other packages. (An earlier revision re-exported an env-proxy helper from@continuedev/fetch; that coupling was removed since dependent packages consume the published@continuedev/fetch, and the tracking issue's mention of a new export no longer applies.)Closes #12008
AI Code Review
@continue-reviewChecklist
Screen recording or screenshot
N/A — library-level change in
packages/openai-adapters(no UI). Behavior is demonstrated by the no-mocks e2e tests below.Tests
All automated tests run against no-mocks local rigs (real local proxy servers, real TLS handshakes against a self-signed mini-CA) — they exercise the real SDK and real sockets, no fetch mocks:
gemini-adapter.vitest.ts(25) — httpOptions construction, error normalization, embed request/response shape, usage accountinggemini-fetch-adaptation.vitest.ts(10) — response adaptation, fast-path vs wrapped-path selection, env-proxy engagement, restore-on-throw, swap serialization (concurrent configs observe only their own fetch), lock released at establishment not stream-endgemini-proxy-e2e.vitest.ts(9) — real local proxy with request hit-counters: proxy routing, custom headers on the wire, embed via proxy, env-var proxy withNO_PROXYbypass, config-over-env precedence, timeout abort, cross-config credential isolation via two simultaneous proxiesgemini-tls-e2e.vitest.ts(6) — mini-CA:caBundlePathtrust,verifySsl: false, mutual TLS withclientCertificate(including passphrase-protected key), handshake rejection without a client certopensslis used by the TLS rig atbeforeAll(present on the CI image; same approach aspackages/fetch's existing e2e tests).Additionally verified manually against the live Google API (real key, outside CI): Google's own live model tests pass once pointed at current model IDs, including tool calls on two models; embeddings return 3072-dim vectors through the SDK-native path.